--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit a81a7dc30ebffa59cd31abcca7b0e81db4a5b891
Parents : d2363e2
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-22T17:38:42-05:00
feat(link-handling): implement centralized rich HTML link handling to manage navigation and external links across components
Changes
11 files changed, 461 insertions(+), 221 deletions(-)
Diff
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index a19989b9..15d9f13c 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -136,6 +136,17 @@ public class MainActivity extends AppCompatActivity {
return "127.0.0.1".equals(h) || "localhost".equals(h) || "[::1]".equals(h) || "::1".equals(h);
}
+ private void openExternalBrowserUri(Uri uri) {
+ if (uri == null) {
+ return;
+ }
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ } catch (ActivityNotFoundException ignored) {
+ // no browser installed
+ }
+ }
+
private final ActivityResultLauncher<Intent> filePickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
@@ -198,15 +209,18 @@ public class MainActivity extends AppCompatActivity {
if (isAllowedWebViewNavigationUri(uri)) {
return false;
}
+ openExternalBrowserUri(uri);
return true;
}
@Override
@SuppressWarnings("deprecation")
public boolean shouldOverrideUrlLoading(WebView view, String url) {
- if (isAllowedWebViewNavigationUri(url != null ? Uri.parse(url) : null)) {
+ Uri uri = url != null ? Uri.parse(url) : null;
+ if (isAllowedWebViewNavigationUri(uri)) {
return false;
}
+ openExternalBrowserUri(uri);
return true;
}
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 39bd9b55..b21cb246 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -11066,13 +11066,38 @@ class ReticulumMeshChat:
node = self.page_node_manager.get_node(node_id)
if not node:
return web.json_response({"message": "Node not found"}, status=404)
- reader = await request.multipart()
- field = await reader.next()
+ try:
+ reader = await request.multipart()
+ field = await reader.next()
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Invalid upload request: {e}"},
+ status=400,
+ )
if field is None:
return web.json_response({"message": "No file uploaded"}, status=400)
filename = field.filename or "upload"
- file_data = await field.read()
- saved_name = node.add_file(filename, file_data)
+ try:
+ file_data = await field.read()
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Failed to read upload: {e}"},
+ status=400,
+ )
+ try:
+ saved_name = node.add_file(filename, file_data)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ except OSError as e:
+ return web.json_response(
+ {"message": f"Failed to write file: {e}"},
+ status=500,
+ )
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Failed to save file: {e}"},
+ status=500,
+ )
return web.json_response({"name": saved_name, "message": "File uploaded"})
@routes.delete("/api/v1/page-nodes/{node_id}/files/{file_name}")
diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index e8b69043..35c0102b 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -242,6 +242,7 @@ import {
isMicronWasmBundled,
} from "../../js/MicronWasmLoader.js";
import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import ArchiveSidebar from "./ArchiveSidebar.vue";
export default {
@@ -531,40 +532,26 @@ export default {
return Utils.formatTimeAgo(dateStr);
},
onArchiveContentClick(event) {
- const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
- if (nomadLink) {
- event.preventDefault();
- event.stopPropagation();
- const url = nomadLink.getAttribute("data-nomadnet-url");
- if (!url) {
- return;
- }
- const [hash, ...pathParts] = url.split(":");
- const path = pathParts.join(":");
- this.$router.push({
- name: "nomadnetwork",
- params: { destinationHash: hash },
- query: { path: path },
- });
- return;
- }
- const fragAnchor = event.target.closest("a[href]");
- if (
- fragAnchor &&
- fragAnchor.getAttribute("href") &&
- fragAnchor.getAttribute("href") !== "#" &&
- fragAnchor.getAttribute("href").startsWith("#") &&
- !fragAnchor.getAttribute("data-nomadnet-url")
- ) {
- event.preventDefault();
- event.stopPropagation();
- const raw = fragAnchor.getAttribute("href").slice(1);
- const id = decodeURIComponent(raw);
- const el = document.getElementById(id);
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
- }
- }
+ handleRichHtmlLinkClick(event, {
+ onNomadUrl: (url) => {
+ const [hash, ...pathParts] = url.split(":");
+ const path = pathParts.join(":");
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ },
+ onOpenNode: (destination) => {
+ const [hash, ...pathParts] = destination.split(":");
+ const path = pathParts.join(":") || "/page/index.mu";
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ },
+ });
},
async downloadTextAsFile(content, filename) {
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 6b04fb12..6acf1dd0 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1781,7 +1781,7 @@ import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
-import LinkUtils from "../../js/LinkUtils";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js";
import { LXMF_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
import { createOutboundQueue } from "../../js/outboundSendQueue";
@@ -2584,62 +2584,38 @@ export default {
},
async handleMessageClick(event) {
const hex32 = /^[a-fA-F0-9]{32}$/;
- const nomadnetLink = event.target.closest(".nomadnet-link");
- if (nomadnetLink) {
- event.preventDefault();
- const url = nomadnetLink.getAttribute("data-nomadnet-url");
- if (url) {
+ const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
+ handleRichHtmlLinkClick(event, {
+ onNomadUrl: (url) => {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
if (!hex32.test(hash)) {
return;
}
- const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
this.$router.push({
name: routeName,
params: { destinationHash: hash },
query: { path: path },
});
- }
- return;
- }
-
- const lxmfLink = event.target.closest(".lxmf-link");
- if (lxmfLink) {
- event.preventDefault();
- const address = lxmfLink.getAttribute("data-lxmf-address");
- if (address && hex32.test(address)) {
+ },
+ onLxmfAddress: (address) => {
this.$router.push({
name: "messages",
params: { destinationHash: address },
});
- }
- return;
- }
-
- const standardLink = event.target.closest("a[href]");
- if (!standardLink) {
- return;
- }
-
- const hrefRaw = String(standardLink.getAttribute("href") || "").trim();
- const safeHttp = LinkUtils.httpUrlHrefOrNull(hrefRaw);
- if (!safeHttp) {
- event.preventDefault();
- return;
- }
-
- event.preventDefault();
- if (this.isStrangerPeer && this.warnOnStrangerLinksEnabled) {
- const proceed = await DialogUtils.confirm(
- this.$t("messages.stranger_link_open_confirm", { url: safeHttp })
- );
- if (!proceed) {
- return;
- }
- }
-
- window.open(safeHttp, "_blank", "noopener,noreferrer");
+ },
+ openExternalHttp: async (safeHttp) => {
+ if (this.isStrangerPeer && this.warnOnStrangerLinksEnabled) {
+ const proceed = await DialogUtils.confirm(
+ this.$t("messages.stranger_link_open_confirm", { url: safeHttp })
+ );
+ if (!proceed) {
+ return;
+ }
+ }
+ window.open(safeHttp, "_blank", "noopener,noreferrer");
+ },
+ });
},
async updatePropagationNodeStatus() {
try {
diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index a535bc4b..0e771445 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -178,6 +178,7 @@ import { micronStorage } from "../../js/MicronStorage";
import { preloadNomadMicronWasm, isMicronWasmBundled } from "../../js/MicronWasmLoader";
import DialogUtils from "../../js/DialogUtils";
import LinkUtils from "../../js/LinkUtils.js";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/;
@@ -264,68 +265,11 @@ export default {
});
},
onPreviewClick(event) {
- const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
- if (nomadLink) {
- event.preventDefault();
- event.stopPropagation();
- const url = nomadLink.getAttribute("data-nomadnet-url");
- if (url) {
- this.openNomadDestination(url);
- }
- return;
- }
-
- const externalAnchor = event.target.closest("a[href]");
- if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
- const href = externalAnchor.getAttribute("href");
- const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
- if (httpHref) {
- event.preventDefault();
- event.stopPropagation();
- this.openExternalHttpUrl(httpHref);
- return;
- }
- }
-
- const fragAnchor = event.target.closest("a[href]");
- if (
- fragAnchor &&
- fragAnchor.getAttribute("href") &&
- fragAnchor.getAttribute("href") !== "#" &&
- fragAnchor.getAttribute("href").startsWith("#") &&
- !fragAnchor.getAttribute("data-nomadnet-url")
- ) {
- event.preventDefault();
- event.stopPropagation();
- const raw = fragAnchor.getAttribute("href").slice(1);
- const id = decodeURIComponent(raw);
- const root = this.$refs.previewRef;
- const el = root ? root.querySelector(`#${CSS.escape(id)}`) : document.getElementById(id);
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
- }
- return;
- }
-
- const nodeLink = event.target.closest('[data-action="openNode"]');
- if (nodeLink) {
- event.preventDefault();
- event.stopPropagation();
- const destination = nodeLink.getAttribute("data-destination");
- if (destination) {
- this.openNomadDestination(destination);
- }
- return;
- }
-
- const anchor = event.target.closest("a[href]");
- if (anchor) {
- const href = (anchor.getAttribute("href") || "").trim();
- if (href && href !== "#" && !href.startsWith("#")) {
- event.preventDefault();
- event.stopPropagation();
- }
- }
+ handleRichHtmlLinkClick(event, {
+ scrollRoot: this.$refs.previewRef,
+ onNomadUrl: (url) => this.openNomadDestination(url),
+ onOpenNode: (destination) => this.openNomadDestination(destination),
+ });
},
renderActiveTab() {
if (this.tabs.length === 0 || !this.tabs[this.activeTabIndex]) {
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 0ef69e0f..331e37b3 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -574,6 +574,7 @@
<script>
import MicronParser from "../../js/MicronParser";
import LinkUtils from "../../js/LinkUtils";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import { renderNomadPageByPath, resolveNomadPageShellBackground } from "../../js/NomadPageRenderer";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
@@ -1202,60 +1203,14 @@ export default {
});
},
onElementClick(event) {
- const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
- if (nomadLink) {
- event.preventDefault();
- event.stopPropagation();
- const url = nomadLink.getAttribute("data-nomadnet-url");
- if (url) {
+ handleRichHtmlLinkClick(event, {
+ onNomadUrl: (url) => {
this.onNodePageUrlClick(url, null, true, false, this.getLinkNavOptions(event));
- }
- return;
- }
-
- const externalAnchor = event.target.closest("a[href]");
- if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
- const href = externalAnchor.getAttribute("href");
- const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
- if (httpHref) {
- event.preventDefault();
- event.stopPropagation();
- window.open(httpHref, "_blank", "noopener,noreferrer");
- return;
- }
- }
-
- const fragAnchor = event.target.closest("a[href]");
- if (
- fragAnchor &&
- fragAnchor.getAttribute("href") &&
- fragAnchor.getAttribute("href") !== "#" &&
- fragAnchor.getAttribute("href").startsWith("#") &&
- !fragAnchor.getAttribute("data-nomadnet-url")
- ) {
- event.preventDefault();
- event.stopPropagation();
- const raw = fragAnchor.getAttribute("href").slice(1);
- const id = decodeURIComponent(raw);
- const el = document.getElementById(id);
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
- }
- return;
- }
-
- const element = event.target.closest('[data-action="openNode"]');
- if (!element) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- const destination = element.getAttribute("data-destination");
- const fields = element.getAttribute("data-fields");
-
- this.onNodePageUrlClick(destination, fields, true, false, this.getLinkNavOptions(event));
+ },
+ onOpenNode: (destination, fields) => {
+ this.onNodePageUrlClick(destination, fields, true, false, this.getLinkNavOptions(event));
+ },
+ });
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index 5372a427..f47f5a0c 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -451,6 +451,7 @@ import ElectronUtils from "../../js/ElectronUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import ToastUtils from "../../js/ToastUtils";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
@@ -832,11 +833,8 @@ export default {
},
handleMessageClick(event) {
const hex32 = /^[a-fA-F0-9]{32}$/;
- const nomadnetLink = event.target.closest(".nomadnet-link");
- if (nomadnetLink) {
- event.preventDefault();
- const url = nomadnetLink.getAttribute("data-nomadnet-url");
- if (url) {
+ handleRichHtmlLinkClick(event, {
+ onNomadUrl: (url) => {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
if (hex32.test(hash)) {
@@ -846,21 +844,14 @@ export default {
query: { path: path },
});
}
- }
- return;
- }
-
- const lxmfLink = event.target.closest(".lxmf-link");
- if (lxmfLink) {
- event.preventDefault();
- const address = lxmfLink.getAttribute("data-lxmf-address");
- if (address && hex32.test(address)) {
+ },
+ onLxmfAddress: (address) => {
this.$router.push({
name: "messages",
params: { destinationHash: address },
});
- }
- }
+ },
+ });
},
},
};
diff --git a/meshchatx/src/frontend/js/NomadRichHtmlLinks.js b/meshchatx/src/frontend/js/NomadRichHtmlLinks.js
new file mode 100644
index 00000000..3729a52a
--- /dev/null
+++ b/meshchatx/src/frontend/js/NomadRichHtmlLinks.js
@@ -0,0 +1,117 @@
+import LinkUtils from "./LinkUtils.js";
+
+const HEX32 = /^[a-fA-F0-9]{32}$/;
+
+export function openExternalHttpUrl(url, opener = window.open.bind(window)) {
+ if (!url) {
+ return;
+ }
+ opener(url, "_blank", "noopener,noreferrer");
+}
+
+function stopEvent(event) {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+}
+
+function scrollToElementId(id, scrollRoot) {
+ const escapedId =
+ typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/"/g, '\\"');
+ const el = scrollRoot ? scrollRoot.querySelector(`#${escapedId}`) : document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+}
+
+/**
+ * Intercepts clicks inside rich HTML (v-html) containers so nomad/mesh links stay
+ * in-app, http(s) links open externally, and other navigation does not trap the shell.
+ *
+ * @param {MouseEvent} event
+ * @param {object} [options]
+ * @param {Element|null} [options.scrollRoot] - scope fragment anchor scrolling
+ * @param {(url: string) => void} [options.onNomadUrl] - data-nomadnet-url handler
+ * @param {(address: string) => void} [options.onLxmfAddress] - data-lxmf-address handler
+ * @param {(destination: string, fields: string|null) => void} [options.onOpenNode]
+ * @param {(url: string) => void|Promise<void>} [options.openExternalHttp]
+ * @param {boolean} [options.blockUnhandledAnchors=true]
+ * @returns {boolean} true when the click was handled
+ */
+export function handleRichHtmlLinkClick(event, options = {}) {
+ const {
+ scrollRoot = null,
+ onNomadUrl,
+ onLxmfAddress,
+ onOpenNode,
+ openExternalHttp = openExternalHttpUrl,
+ blockUnhandledAnchors = true,
+ } = options;
+
+ const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
+ if (nomadLink && onNomadUrl) {
+ stopEvent(event);
+ const url = nomadLink.getAttribute("data-nomadnet-url");
+ if (url) {
+ onNomadUrl(url);
+ }
+ return true;
+ }
+
+ const lxmfLink = event.target.closest("a.lxmf-link[data-lxmf-address]");
+ if (lxmfLink && onLxmfAddress) {
+ stopEvent(event);
+ const address = lxmfLink.getAttribute("data-lxmf-address");
+ if (address && HEX32.test(address)) {
+ onLxmfAddress(address);
+ }
+ return true;
+ }
+
+ const externalAnchor = event.target.closest("a[href]");
+ if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
+ const href = externalAnchor.getAttribute("href");
+ const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
+ if (httpHref) {
+ stopEvent(event);
+ openExternalHttp(httpHref);
+ return true;
+ }
+ }
+
+ const fragAnchor = event.target.closest("a[href]");
+ if (
+ fragAnchor &&
+ fragAnchor.getAttribute("href") &&
+ fragAnchor.getAttribute("href") !== "#" &&
+ fragAnchor.getAttribute("href").startsWith("#") &&
+ !fragAnchor.getAttribute("data-nomadnet-url")
+ ) {
+ stopEvent(event);
+ const raw = fragAnchor.getAttribute("href").slice(1);
+ scrollToElementId(decodeURIComponent(raw), scrollRoot);
+ return true;
+ }
+
+ const nodeLink = event.target.closest('[data-action="openNode"]');
+ if (nodeLink && onOpenNode) {
+ stopEvent(event);
+ const destination = nodeLink.getAttribute("data-destination");
+ if (destination) {
+ onOpenNode(destination, nodeLink.getAttribute("data-fields"));
+ }
+ return true;
+ }
+
+ if (blockUnhandledAnchors) {
+ const anchor = event.target.closest("a[href]");
+ if (anchor) {
+ const href = (anchor.getAttribute("href") || "").trim();
+ if (href && href !== "#" && !href.startsWith("#")) {
+ stopEvent(event);
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
diff --git a/tests/frontend/NomadRichHtmlLinks.test.js b/tests/frontend/NomadRichHtmlLinks.test.js
new file mode 100644
index 00000000..848050c8
--- /dev/null
+++ b/tests/frontend/NomadRichHtmlLinks.test.js
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { handleRichHtmlLinkClick, openExternalHttpUrl } from "@/js/NomadRichHtmlLinks.js";
+
+function clickEvent(target) {
+ const ev = new MouseEvent("click", { bubbles: true, cancelable: true });
+ Object.defineProperty(ev, "target", { value: target });
+ return ev;
+}
+
+describe("NomadRichHtmlLinks", () => {
+ describe("openExternalHttpUrl", () => {
+ it("opens http(s) in a new window", () => {
+ const opener = vi.fn();
+ openExternalHttpUrl("https://example.com", opener);
+ expect(opener).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer");
+ });
+
+ it("ignores empty urls", () => {
+ const opener = vi.fn();
+ openExternalHttpUrl("", opener);
+ openExternalHttpUrl(null, opener);
+ expect(opener).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("handleRichHtmlLinkClick", () => {
+ let holder;
+
+ beforeEach(() => {
+ holder = document.createElement("div");
+ document.body.appendChild(holder);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(holder);
+ });
+
+ it("routes nomadnet links through onNomadUrl", () => {
+ holder.innerHTML = '<a class="nomadnet-link" data-nomadnet-url="abc123:/page/index.mu">n</a>';
+ const onNomadUrl = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { onNomadUrl })).toBe(true);
+ expect(onNomadUrl).toHaveBeenCalledWith("abc123:/page/index.mu");
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("opens http links externally", () => {
+ holder.innerHTML = '<a href="https://example.com">x</a>';
+ const openExternalHttp = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { openExternalHttp })).toBe(true);
+ expect(openExternalHttp).toHaveBeenCalledWith("https://example.com/");
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("scrolls fragment anchors within scrollRoot", () => {
+ holder.innerHTML = '<div id="target">t</div><a href="#target">f</a>';
+ const target = holder.querySelector("#target");
+ target.scrollIntoView = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { scrollRoot: holder })).toBe(true);
+ expect(target.scrollIntoView).toHaveBeenCalled();
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("blocks unhandled anchor navigation by default", () => {
+ holder.innerHTML = '<a href="javascript:alert(1)">bad</a>';
+ const openExternalHttp = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { openExternalHttp })).toBe(true);
+ expect(ev.defaultPrevented).toBe(true);
+ expect(openExternalHttp).not.toHaveBeenCalled();
+ });
+
+ it("routes lxmf links through onLxmfAddress for valid 32-char hex", () => {
+ const hash = "a".repeat(32);
+ holder.innerHTML = `<a class="lxmf-link" data-lxmf-address="${hash}">lx</a>`;
+ const onLxmfAddress = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { onLxmfAddress })).toBe(true);
+ expect(onLxmfAddress).toHaveBeenCalledWith(hash);
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("rejects invalid lxmf addresses", () => {
+ holder.innerHTML = '<a class="lxmf-link" data-lxmf-address="not-a-valid-hash">lx</a>';
+ const onLxmfAddress = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { onLxmfAddress })).toBe(true);
+ expect(onLxmfAddress).not.toHaveBeenCalled();
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("routes openNode actions through onOpenNode", () => {
+ holder.innerHTML = '<span data-action="openNode" data-destination="deadbeef:/page/x.mu">go</span>';
+ const onOpenNode = vi.fn();
+ const ev = clickEvent(holder.querySelector("[data-action=openNode]"));
+ expect(handleRichHtmlLinkClick(ev, { onOpenNode })).toBe(true);
+ expect(onOpenNode).toHaveBeenCalledWith("deadbeef:/page/x.mu", null);
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
+ it("returns false for unrelated clicks", () => {
+ holder.innerHTML = "<span>plain text</span>";
+ const ev = clickEvent(holder.querySelector("span"));
+ expect(handleRichHtmlLinkClick(ev, {})).toBe(false);
+ expect(ev.defaultPrevented).toBe(false);
+ });
+ });
+});
diff --git a/tests/frontend/archivesPage.security.test.js b/tests/frontend/archivesPage.security.test.js
index 6fee4e90..3fdc12d2 100644
--- a/tests/frontend/archivesPage.security.test.js
+++ b/tests/frontend/archivesPage.security.test.js
@@ -32,6 +32,15 @@ function randText(len) {
return s;
}
+function assertNoDangerousHtmlPatterns(html) {
+ const lower = html.toLowerCase();
+ expect(lower).not.toContain("<script");
+ expect(lower).not.toContain("<iframe");
+ expect(lower).not.toContain("javascript:");
+ expect(lower).not.toMatch(/<[^>]*\bonerror\s*=/);
+ expect(lower).not.toMatch(/<[^>]*\bonload\s*=/);
+}
+
describe("Archives page viewing-archive surface (security / fuzz)", () => {
const nastyPaths = [
"/page/article.mu`topic_id=40",
@@ -51,6 +60,18 @@ describe("Archives page viewing-archive surface (security / fuzz)", () => {
"\x00".repeat(20),
];
+ it("renderFullContent sanitizes .html archives", () => {
+ const { wrapper } = mountArchives();
+ const out = wrapper.vm.renderFullContent({
+ page_path: "/page/evil.html",
+ content: '<body><img src=x onerror=alert(1)><a href="javascript:alert(1)">x</a></body>',
+ destination_hash: "a".repeat(64),
+ hash: "b".repeat(64),
+ id: 1,
+ });
+ assertNoDangerousHtmlPatterns(out);
+ });
+
it("renderFullContent never throws; returns a string for fuzzed paths and bodies", () => {
const { wrapper } = mountArchives();
for (let i = 0; i < 90; i++) {
@@ -66,14 +87,29 @@ describe("Archives page viewing-archive surface (security / fuzz)", () => {
expect(() => wrapper.vm.renderFullContent(archive)).not.toThrow();
const out = wrapper.vm.renderFullContent(archive);
expect(typeof out).toBe("string");
+ if ((archive.page_path || "").split("`")[0].toLowerCase().endsWith(".html")) {
+ assertNoDangerousHtmlPatterns(out);
+ }
}
});
- it("archiveViewerClasses stays an array for adversarial page_path values", () => {
+ it("archiveViewerClasses picks safe viewer classes for adversarial page_path values", () => {
const { wrapper } = mountArchives();
- for (const page_path of nastyPaths) {
+ const cases = [
+ { page_path: "/page/article.mu`topic_id=40", expectRich: true },
+ { page_path: "/page/readme.html", expectRich: true, expectHtml: true },
+ { page_path: "javascript:alert(1)", expectRich: false },
+ { page_path: "../../../etc/passwd", expectRich: false },
+ ];
+ for (const { page_path, expectRich, expectHtml } of cases) {
wrapper.vm.viewingArchive = { page_path };
- expect(Array.isArray(wrapper.vm.archiveViewerClasses)).toBe(true);
+ const classes = wrapper.vm.archiveViewerClasses;
+ expect(Array.isArray(classes)).toBe(true);
+ expect(classes).toContain("wrap-break-word");
+ expect(classes.includes("nomad-page-rich")).toBe(expectRich);
+ if (expectHtml) {
+ expect(classes).toContain("nomad-page-html-host");
+ }
}
wrapper.vm.viewingArchive = null;
});
@@ -105,28 +141,35 @@ describe("Archives page viewing-archive surface (security / fuzz)", () => {
expect(base.includes("..")).toBe(false);
});
- it("onArchiveContentClick handles nomadnet links and fragment anchors without throwing", () => {
+ it("onArchiveContentClick routes nomadnet links and scrolls fragment anchors", () => {
const { wrapper, routerPush } = mountArchives();
const holder = document.createElement("div");
holder.innerHTML =
- '<a class="nomadnet-link" data-nomadnet-url="abc123:/p.mu`q=1">n</a>' + '<a href="#frag%20ment">f</a>';
+ '<a class="nomadnet-link" data-nomadnet-url="abc123:/p.mu`q=1">n</a>' +
+ '<div id="frag ment">target</div><a href="#frag%20ment">f</a>';
document.body.appendChild(holder);
try {
const nomadA = holder.querySelector("a.nomadnet-link");
+ const fragTarget = holder.querySelector("#frag\\ ment");
+ fragTarget.scrollIntoView = vi.fn();
const fragA = holder.querySelector('a[href^="#"]');
const clickOn = (el) => {
- const ev = new MouseEvent("click", { bubbles: true });
+ const ev = new MouseEvent("click", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "target", { value: el });
wrapper.vm.onArchiveContentClick(ev);
+ return ev;
};
- clickOn(nomadA);
+ const nomadEv = clickOn(nomadA);
expect(routerPush).toHaveBeenCalledWith({
name: "nomadnetwork",
params: { destinationHash: "abc123" },
query: { path: "/p.mu`q=1" },
});
+ expect(nomadEv.defaultPrevented).toBe(true);
routerPush.mockClear();
- clickOn(fragA);
+ const fragEv = clickOn(fragA);
+ expect(fragEv.defaultPrevented).toBe(true);
+ expect(fragTarget.scrollIntoView).toHaveBeenCalled();
} finally {
document.body.removeChild(holder);
}
@@ -135,4 +178,43 @@ describe("Archives page viewing-archive surface (security / fuzz)", () => {
Object.defineProperty(noopEv, "target", { value: noop });
expect(() => wrapper.vm.onArchiveContentClick(noopEv)).not.toThrow();
});
+
+ it("onArchiveContentClick opens http links externally", async () => {
+ const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
+ const { wrapper } = mountArchives();
+ const holder = document.createElement("div");
+ holder.innerHTML = '<a href="https://example.com/page">Example</a>';
+ document.body.appendChild(holder);
+ try {
+ const link = holder.querySelector("a");
+ const ev = new MouseEvent("click", { bubbles: true, cancelable: true });
+ Object.defineProperty(ev, "target", { value: link });
+ wrapper.vm.onArchiveContentClick(ev);
+ expect(openSpy).toHaveBeenCalledWith("https://example.com/page", "_blank", "noopener,noreferrer");
+ expect(ev.defaultPrevented).toBe(true);
+ } finally {
+ document.body.removeChild(holder);
+ openSpy.mockRestore();
+ }
+ });
+
+ it("onArchiveContentClick blocks javascript: anchors without opening a window", () => {
+ const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
+ const { wrapper, routerPush } = mountArchives();
+ const holder = document.createElement("div");
+ holder.innerHTML = '<a href="javascript:alert(1)">bad</a>';
+ document.body.appendChild(holder);
+ try {
+ const link = holder.querySelector("a");
+ const ev = new MouseEvent("click", { bubbles: true, cancelable: true });
+ Object.defineProperty(ev, "target", { value: link });
+ wrapper.vm.onArchiveContentClick(ev);
+ expect(ev.defaultPrevented).toBe(true);
+ expect(openSpy).not.toHaveBeenCalled();
+ expect(routerPush).not.toHaveBeenCalled();
+ } finally {
+ document.body.removeChild(holder);
+ openSpy.mockRestore();
+ }
+ });
});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index b75d7816..13a697bc 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -95,6 +95,45 @@ describe("behavior contracts: user-visible wiring must stay connected", () => {
expect(meshchat).toContain("async def page_nodes_upload_file");
expect(meshchat).toContain("node.add_file(filename, file_data)");
});
+
+ it("page node upload returns JSON errors instead of unhandled 500s", () => {
+ const meshchat = readSource("meshchatx/meshchat.py");
+ const start = meshchat.indexOf("async def page_nodes_upload_file");
+ expect(start).toBeGreaterThan(-1);
+ const end = meshchat.indexOf("async def page_nodes_delete_file", start);
+ const block = meshchat.slice(start, end);
+ expect(block).toContain("except ValueError as e:");
+ expect(block).toContain("except OSError as e:");
+ expect(block).toContain("Failed to write file:");
+ });
+ });
+
+ describe("rich html link policy", () => {
+ const surfaces = [
+ ["MicronEditorPage.vue", "meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue"],
+ ["NomadNetworkPage.vue", "meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue"],
+ ["ArchivesPage.vue", "meshchatx/src/frontend/components/archives/ArchivesPage.vue"],
+ ["RNCPPage.vue", "meshchatx/src/frontend/components/rncp/RNCPPage.vue"],
+ ["ConversationViewer.vue", "meshchatx/src/frontend/components/messages/ConversationViewer.vue"],
+ ];
+
+ it.each(surfaces)("%s uses shared rich html link handler", (_, relativePath) => {
+ const src = readSource(relativePath);
+ expect(src).toContain("NomadRichHtmlLinks");
+ expect(src).toContain("handleRichHtmlLinkClick");
+ });
+
+ it("electron main attaches in-window navigation and popout guards", () => {
+ const main = readSource("electron/main.js");
+ expect(main).toContain("attachInWindowNavigationGuard");
+ expect(main).toContain("setWindowOpenHandler");
+ });
+
+ it("Android WebView opens external http(s) in the system browser", () => {
+ const src = readSource("android/app/src/main/java/com/meshchatx/MainActivity.java");
+ expect(src).toContain("openExternalBrowserUri");
+ expect(src).toContain("Intent.ACTION_VIEW");
+ });
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────